Every demo application's last_activity_at is boot time: the stamp hook overwrites the seeded value unconditionally - #74
Conversation
`ApplicationStampHook` assigned `last_activity_at = now()` outside every guard, on `beforeInsert` and `beforeUpdate` alike, so the seed's authored `activityDaysAgo` never reached the database: all 200 demo applications read one identical boot-time instant — one instant, not a spread, because `claimSeedOwnership`'s `multi: true` claim applies a single SET clause to every matched row. Guard the assignment on both events. An authored value wins; a filing is activity; an update is activity when its payload names a field of the application rather than one of the platform's own bookkeeping columns (`owner_id`, the engine's audit columns) or the `days_to_offer` metric a sibling hook stamps. The chosen rule and every exception is written down in the hook's header, and the stale paragraph in `OfferTimeToOfferHook` that described the unconditional refresh is corrected there. Guarding only the insert would have been a boot-1 fix: the ownership claim and the seed's own re-boot upsert wipe restored history on boot 2 of a persistent database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
`interview_count` is a `Field.summary` the engine recomputes when an
interview row lands; measured on a seeded boot it reaches the update path
as `{id, interview_count, updated_at}` for the 28 interview-bearing
applications. Like `days_to_offer` it restates a fact whose timestamp
lives on the other row, and counting it re-dated those 28 rows to boot
time on boot 1 and not on the next — the same per-boot instability the
metric exemption avoids.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The header now says what this branch measured — 200 identical values at the end of boot 1, the claim pass as the boot's last write to the row, and the 200-of-200 change across two boots before the guard — and records that #65's own read of the same table was a 0.7s window, which does not change either conclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Review — reproduced the whole thing on my own two boots. It holds.Nine lines of logic, and the reasoning behind them is the part that matters, so I re-ran the acceptance rather than reading the tables. The distribution is the seed's, checked against a parse the fix never touchesBooted on a persistent sqlite file, waited for the seeder and the security bootstrap, then read 28 calendar days where The two-boot test, on one file, stopped fully betweenThis is the acceptance the card's own suggested one-liner would have failed, and it is the reason I wrote the dispatch the way I did. You went past the suggestion rather than implementing it — right call. The guard is precise in both directions, not merely conservativeA guard that stops the wipe by never stamping would also freeze the timeline for real users, so I drove the matrix myself as
Three judgements I want to endorse explicitly, because each could have gone the lazy way
One property worth naming, not a defectClause 1 makes Not re-run
The side effect I like most: the seeder line goes from Merging. #72 and #73 are correctly filed rather than ridden along — #72 especially, since a header claiming seed rows bypass hooks via Generated by Claude Code |
Fixes #65
ApplicationStampHookended withinput.last_activity_at = new Date().toISOString()outside every guard, onbeforeInsertandbeforeUpdatealike, sobuildApplications' authoreddaysAgo(a.activityDaysAgo)never survived the write. Shape (1) from the card, with the update path guarded too — the card's suggested one-liner fixes boot 1 and leavesclaimSeedOwnershipto wipe the history on boot 2.The rule this PR chose, and where it is written down
Full reasoning is in the hook's header, section "What counts as activity on an application". Three clauses:
== null, so a client that sends the key asnullstill gets a stamp.applied_atalready had.owner_id(plugin-security's boot-time claim),days_to_offerandinterview_count(the object's two non-authorable derived columns — one stamped byats_offer_time_to_offer, one aField.summaryroll-up), and the engine's ownid/created_at/created_by/updated_at/updated_by.A deny-list, not an allow-list: a field added to the object tomorrow counts as activity without anyone remembering to list it, and the failure direction is one stamp too many rather than a timeline frozen at boot.
Why the two derived columns are exempt — measured, not assumed
Instrumenting the handler to log its payload keys on a seeded boot shows exactly three update shapes reaching
ats_application, and not one of them is a person acting on the row:Both derived writes restate a fact whose own timestamp lives on the other row (the offer's
created_at, the interview'sscheduled_at), and the recruiter's act that produced it — moving the application tostage: offer/stage: interview— is a payload that does name a field of the application and does stamp. Counting them would re-date those 23 + 28 rows to boot time on boot 1 and not on the next (both parent rows are inserted once and upserted after): a demo dataset that changes shape per boot. The now-stale paragraph inOfferTimeToOfferHook's header, which called that refresh "the right answer rather than a side effect to suppress", is corrected in the same file.Acceptance 2 — the distribution, not one row
Boot with the demo seed on a persistent sqlite file, wait for
[Seeder] Seed loading complete, then readats_applicationstraight out of the file.Before (
origin/main= 54cd69c):All 200 identical to the millisecond — not a spread — because the boot's last write to these rows is the
multi: trueownership claim, whose single SET clause carries the last matched row's value to every matched row. (#65 read the same table as a 0.7s window on its author's boot; either way the column is boot time and either way the producing write is on the update path.)After (this branch):
It is the seed's spread, not merely a spread. The expected distribution was computed independently from
APPLICATIONS[i].activityDaysAgoinsrc/data/shared/pipeline.tsagainst the boot day and compared to the values read out of sqlite:The
last_activity_at >= applied_atinvariant holds in the database for all 200 rows (and the seed itself authors 0 rows withactivityDaysAgo > appliedDaysAgo, so the two agree).Acceptance 3 — the two-boot test on one sqlite file
Same file, server fully stopped between boots, values dumped after
platform bootstrap completeeach time.{"inserted":818,"updated":0,"skipped":0,"errored":0}{"inserted":0,"updated":7,"skipped":811,"errored":0}{"seeded":8,"adminPromoted":true,"ownershipClaimed":544,…}{"seeded":8,"adminPromoted":false,"reason":"already_have_admin",…}last_activity_atThe same two boots on
origin/main, for contrast — this is the failure the insert-only guard would have shipped:Note the seeder line changes shape with the fix: 207 updated before (every application drifted from its authored row and was rewritten), 7 after (the applications now match the seed, so they are skipped). The fix makes the demo seed idempotent.
Acceptance 4 — a real update still stamps, a bookkeeping payload does not
Signed in as
admin@quillstone.example(employer administrator) with anOriginheader;GET /api/v1/data/ats_applicationanswerstotal: 27, hasMore: false, records: 27(this employer's own rows).last_activity_atafterupdated_atafter2026-08-22T00:00:00.000Z2026-09-07T19:11:53.369Z{"rating":4}2026-09-07T19:13:34.358Z(now)2026-09-07T19:13:34.359Z{"stage":"screening"}2026-09-07T19:13:50.563Z(now)2026-09-07T19:13:50.564Z{"last_activity_at":"2026-08-22T00:00:00.000Z"}2026-08-22T00:00:00.000Z(the caller's value, clause 1)2026-09-07T19:13:50.751Z{"owner_id":"usr_ats_0_owner"}2026-08-22T00:00:00.000Z(unchanged)2026-09-07T19:14:07.033ZThe last row is the claim's payload shape done by hand: the write happened (
updated_atmoved), the activity clock did not. At boot scale that same shape is the 200-row claim pass, and the two derived shapes above are the other 51 non-activity dispatches.Acceptance 5 — ripple check, measured
last_activity_atappears in exactly one view definition — the seeker'sminetimeline insrc/views/application.view.ts, as a column. Every application view sorts onconst sort = [{ field: 'applied_at', order: 'desc' }], and the timeline'sstartDateFieldisapplied_at. No view's row ORDER changes; the one thing that changes is the value shown in that column, which is the point of the card.grep -rln last_activity src/hits only the object, the hook, the view, the seed builder, F4's comment and the two translation bundles). The "Applications Awaiting Action" KPI everyone suspects isfilter: { stage: { $in: ['applied','screening'] } }— stage, not recency.applied 88 · screening 46 · interview 28 · offer 14 · hired 9, plusrejected 15 · withdrawn 0, over the seed's 818 rows. Unmoved.docs/screenshots/photographs a surface that renders this column:01/02are dashboards,03a dashboard,04/07the kanban,05the interview calendar,06the job grid. The seeker "My Applications" timeline — the only place the column is rendered — is not among them. No screenshot moves; none retaken.Acceptance 6 — the sibling objects
Only two other now-stamps exist in
src/hooks/, and both are already guarded:ats_inquiry.submitted_at(if (inserting && input.submitted_at == null)) andats_inquiry.converted_at(written only on the update that movesstatustoconverted).ats_interview,ats_offer,ats_candidateandats_candidate_credentialhave no timestamp assignment at all — their stamps writedisplay_name/employer/employer_org/candidate_user, each behindinserting || touched([...]). Nothing else to fix in this PR.Confirmed in the database rather than only by reading, on the same seeded boot — every sibling's seeded datetime column keeps its authored spread:
(
ats_offer.created_atreads boot time on all 23 rows — that is the engine's own audit column, not a seeded one.)Gates
Each exit code captured before any pipe.
pnpm lint's threeapproval-approvers-may-resolve-emptysuggestions are pre-existing onorigin/mainand untouched here.The handler stays lowerable to a metadata-only body —
Skipping legacy runtime bundle (all 9 callables are body-only), and the guard is present in the artifact with the inferredcapabilities: ["api.read"]:Scope
One file,
src/hooks/stamp.hook.ts. No seed change was needed — the seed already authorsactivityDaysAgoper row, and this branch proves the value now arrives intact.src/security/,src/objects/,src/views/,src/apps/,src/dashboards/,src/data/andsrc/flows/are untouched.Two out-of-scope findings were filed rather than ridden along:
build.tssays seed rows skip the stamp hooks (skipTriggers); measured, they do not #72 —build.ts's header explains the explicit denormalised seed values with "a seed row does not go through the stamp hooks (skipTriggers)". Measured false:skipTriggersappears only in that comment, and seed writes dispatch the hooks (that is how Every demo application'slast_activity_atis boot time: the stamp hook overwrites the seeded value unconditionally #65 exists).last_activity_atstamp as unconditional; it is guarded since #65 #73 — F4's header still describes this stamp as unconditional. Its conclusion is unaffected (a stage change is still a payload that stamps), andsrc/flows/was out of bounds for this PR.Generated by Claude Code